/**
 * globes - a set of models of the earth, each having their own kind of projection and onscreen behavior.
 *
 * Copyright (c) 2020 Cameron Beccario
 *
 * For a free version of this project, see https://github.com/cambecc/earth
 */

import {τ} from "../util/consts.js";
// import * as _ from "../lib/underscore.js";
import * as _ from "underscore";
// import * as d3 from "../lib/d3.js";
import * as d3 from "d3";
import {clamp, floorMod} from "../util/math.js";
import {ø} from "../util/objects.js";
import * as twod from "../canvas/twod.js";
import {
    newConicEquidistant,
    newEquirectangular,
    newMollweide,
    newOrthographic,
    newPatterson,
    newPolyhedralWaterman,
    newStereographic,
    newWinkel3,
} from "../projection/projections.js";

/**
 * @returns {Array} rotation of globe to current position of the user. Aside from asking for geolocation,
 *          which user may reject, there is not much available except timezone. Better than nothing.
 */
function currentPosition() {
    const λ = floorMod(new Date().getTimezoneOffset() / 4, 360);  // 24 hours * 60 min / 4 === 360 degrees
    return [λ, 0];
}

function ensureNumber(num, fallback) {
    return _.isFinite(num) || num === Infinity || num === -Infinity ? num : fallback;
}

function fitProjection(proj, view) {
    const [[x0, y0], [x1, y1]] = d3.geoPath().projection(proj).bounds({type: "Sphere"});
    const hScale = (x1 - x0) / proj.scale();
    const vScale = (y1 - y0) / proj.scale();
    const fit = Math.min(view.width / hScale, view.height / vScale) * 0.9;
    const center = [view.width / 2, view.height / 2];
    return proj.scale(fit).translate(center);
}

/**
 * Returns a globe object with standard behavior. At least the newProjection method must be overridden to
 * be functional.
 */
function standardGlobe() {
    return {
        /**
         * This globe's current D3 projection.
         */
        projection: null,

        /**
         * @param view the size of the view as {width:, height:}.
         * @returns {Object} a new D3 projection of this globe appropriate for the specified view port.
         */
        newProjection(view) {
            throw new Error("method must be overridden");
        },

        /**
         * @param view the size of the view as {width:, height:}.
         * @returns {{xMin: number, yMin: number, xMax: number, yMax: number, width: number, height: number}}
         *          the bounds of the current projection clamped to the specified view.
         */
        bounds(view) {
            // CONSIDER: set clipExtent on the projection and get a lot of the following for free.
            //           The clipExtent would essentially become the canonical store for the "view".
            const [[x0, y0], [x1, y1]] = d3.geoPath().projection(this.projection).bounds({type: "Sphere"});
            const xMin = Math.max(Math.floor(ensureNumber(x0, 0)), 0);
            const yMin = Math.max(Math.floor(ensureNumber(y0, 0)), 0);
            const xMax = Math.min(Math.ceil(ensureNumber(x1, view.width)), view.width - 1);
            const yMax = Math.min(Math.ceil(ensureNumber(y1, view.height)), view.height - 1);
            return {xMin, yMin, xMax, yMax, width: xMax - xMin + 1, height: yMax - yMin + 1};
        },

        /**
         * @param view the size of the view as {width:, height:}.
         * @returns {Number} the projection scale at which the entire globe fits within the specified view.
         */
        fit(view) {
            return this.newProjection(view).scale();
        },

        /**
         * @param view the size of the view as {width:, height:}.
         * @returns {Array} the projection transform at which the globe is centered within the specified view.
         */
        center(view) {
            return [view.width / 2, view.height / 2];
        },

        /**
         * @returns {Array} the range at which this globe can be zoomed.
         */
        scaleExtent() {
            return [25, 25000];
        },

        /**
         * @returns {{rotate: number[], scale: number}} the current orientation of this globe
         */
        getOrientation() {
            const proj = this.projection;
            const rotate = _.first(proj.rotate(), 2);
            const scale = proj.scale();
            return ø({rotate, scale});
        },

        /**
         * Changes the projection of this globe to match the specified orientation and view.
         *
         * @param {{rotate: number[], scale: number}} orientation
         * @param {{xMin: number, yMin: number, xMax: number, yMax: number, width: number, height: number}} view
         */
        setOrientation(orientation, view) {
            const proj = this.projection;
            const λ = +(orientation?.rotate?.[0]);
            const φ = +(orientation?.rotate?.[1]);
            const γ = proj.rotate()[2];
            const rotate = λ === λ && φ === φ ? [λ, φ, γ] : this.newProjection(view).rotate();

            const k = +(orientation?.scale);
            const scale = k === k ? clamp(k, ...this.scaleExtent()) : this.fit(view);

            proj.rotate(rotate).scale(scale).translate(this.center(view));
        },

        /**
         * Returns an object that mutates this globe's current projection during a drag/zoom operation.
         * Each drag/zoom event invokes the move() method, and when the move is complete, the end() method
         * is invoked.
         *
         * @param startMouse starting mouse position.
         * @param startScale starting scale.
         */
        manipulator(startMouse, startScale) {
            const projection = this.projection;
            const sensitivity = 60 / startScale;  // seems to provide a good drag scaling factor
            const rotation = [projection.rotate()[0] / sensitivity, -projection.rotate()[1] / sensitivity];
            const original = projection.precision();
            projection.precision(original * 10);
            return {
                move: function(mouse, scale) {
                    if (mouse) {
                        const xd = mouse[0] - startMouse[0] + rotation[0];
                        const yd = mouse[1] - startMouse[1] + rotation[1];
                        projection.rotate([xd * sensitivity, -yd * sensitivity, projection.rotate()[2]]);
                    }
                    projection.scale(scale);
                },
                end: function() {
                    projection.precision(original);
                }
            };
        },

        /**
         * @returns {Array} the transform to apply, if any, to orient this globe to the specified coordinates.
         */
        locate(coord) {
            return null;
        },

        /**
         * Draws a polygon on the specified context of this globe's boundary.
         * @param context a Canvas element's 2d context.
         * @returns the context
         */
        defineMask(context) {
            d3.geoPath().projection(this.projection).context(context)({type: "Sphere"});
            return context;
        },

        backgroundRenderer() {
            return {
                renderTo(context, path) {
                    context.fillStyle = "#303030";
                    context.beginPath();
                    path({type: "Sphere"});
                    context.fill();
                },
            };
        },

        graticuleRenderer() {
            const graticuleMesh = d3.geoGraticule()();
            const hemisphereMesh = d3.geoGraticule().extentMinor([[0, 0], [0, 0]]).stepMajor([0, 90])();
            const graticule = twod.makeStrokeRenderer(graticuleMesh, {strokeStyle: "rgba(180,180,180,0.20)", lineWidth: 1});
            const hemisphere = twod.makeStrokeRenderer(hemisphereMesh, {strokeStyle: "rgba(250,250,250,0.20)", lineWidth: 1});
            return {
                renderTo(context, path) {
                    graticule.renderTo(context, path);
                    hemisphere.renderTo(context, path);
                },
            };
        },

        preForegroundRenderer() {
            return {
                renderTo(context, path) {
                },
            };
        },

        postForegroundRenderer() {
            return {
                renderTo(context, path) {
                    context.strokeStyle = "#000005";
                    context.lineWidth = 3;
                    context.beginPath();
                    path({type: "Sphere"});
                    context.stroke();
                },
            };
        },
    };
}

function newGlobe(source, view) {
    const result = Object.assign(standardGlobe(), source);
    result.projection = result.newProjection(view);
    return result;
}

// ============================================================================================

export function atlantis(view) {
    return newGlobe({
        newProjection(view) {
            return fitProjection(newMollweide().rotate([30, -45, 90]).precision(0.1), view);
        }
    }, view);
}

export function conic_equidistant(view) {
    return newGlobe({
        newProjection(view) {
            return fitProjection(newConicEquidistant().rotate(currentPosition()).precision(0.1), view);
        },
    }, view);
}

export function equirectangular(view) {
    return newGlobe({
        newProjection(view) {
            return fitProjection(newEquirectangular().rotate(currentPosition()).precision(0.1), view);
        },
    }, view);
}

export function orthographic(view) {
    return newGlobe({
        newProjection(view) {
            return fitProjection(newOrthographic().rotate(currentPosition()).precision(0.1).clipAngle(90), view);
        },
        backgroundRenderer() {
            return {
                renderTo(context, path) {
                    const proj = path.projection(), [x, y] = proj.translate(), scale = proj.scale();
                    const gradient = context.createRadialGradient(x, y, 0, x, y, scale);
                    gradient.addColorStop(0.690, "#303030");
                    gradient.addColorStop(0.910, "#202020");
                    gradient.addColorStop(0.999, "#000005");
                    gradient.addColorStop(1.000, "rgba(0,0,0,0)");
                    context.fillStyle = gradient;
                    context.fillRect(x - scale, y - scale, scale * 2, scale * 2);
                },
            };
        },
        postForegroundRenderer() {
            return {
                renderTo(context, path) {
                    const proj = path.projection();
                    const [x, y] = proj.translate();
                    const scale = proj.scale();
                    context.fillStyle = "#000005";
                    context.beginPath();
                    context.arc(x, y, scale - 2, 0, τ, true);
                    context.arc(x, y, scale + 4, 0, τ, true);
                    context.fill("evenodd");
                },
            };
        },
        locate(coord) {
            return [-coord[0], -coord[1], this.projection.rotate()[2]];
        }
    }, view);
}

export function patterson(view) {
    return newGlobe({
        newProjection(view) {
            return fitProjection(newPatterson().precision(0.1), view);
        }
    }, view);
}

export function stereographic(view) {
    return newGlobe({
        newProjection(view) {
            return fitProjection(
                newStereographic()
                    .rotate([-43, -20])
                    .precision(1.0)
                    .clipAngle(180 - 0.0001)
                    .clipExtent([[0, 0], [view.width, view.height]]),
                view);
        },
        postForegroundRenderer() {
            return {
                renderTo(context, path) {
                    // no foreground because always fullscreen
                },
            };
        },
    }, view);
}

export function waterman(view) {
    return newGlobe({
        newProjection(view) {
            return fitProjection(newPolyhedralWaterman().rotate([20, 0]).precision(0.1), view);
        },
        preForegroundRenderer() {
            return {
                renderTo(context, path) {
                    context.save();
                    context.beginPath();
                    path({type: "Sphere"});
                    context.clip();
                },
            };
        },
        postForegroundRenderer() {
            return {
                renderTo(context, path) {
                    context.restore();
                    context.strokeStyle = "#000005";
                    context.lineWidth = 3;
                    context.beginPath();
                    path({type: "Sphere"});
                    context.stroke();
                },
            };
        },
    }, view);
}

export function winkel3(view) {
    return newGlobe({
        newProjection(view) {
            return fitProjection(newWinkel3().precision(0.1), view);
        },
    }, view);
}
